|
Server : LiteSpeed System : Linux host 5.15.0-161-generic #171-Ubuntu SMP Sat Oct 11 08:17:01 UTC 2025 x86_64 User : idnco5810 ( 1093) PHP Version : 8.2.29 Disable Function : NONE Directory : /proc/thread-self/cwd/wp-content/plugins/amp/includes/validation/ |
Upload File : |
<?php
/**
* Class AMP_Validation_Manager
*
* @package AMP
*/
use AmpProject\AmpWP\DevTools\UserAccess;
use AmpProject\AmpWP\Icon;
use AmpProject\AmpWP\Option;
use AmpProject\AmpWP\QueryVar;
use AmpProject\AmpWP\Services;
use AmpProject\Dom\Document;
use AmpProject\Exception\MaxCssByteCountExceeded;
use AmpProject\Html\Attribute;
use AmpProject\Html\Tag;
/**
* Class AMP_Validation_Manager
*
* @since 0.7
* @internal
*/
class AMP_Validation_Manager {
/**
* Query var that triggers validation.
*
* @var string
*/
const VALIDATE_QUERY_VAR = 'amp_validate';
/**
* Key for amp_validate query var array for nonce to authorize validation.
*
* @var string
*/
const VALIDATE_QUERY_VAR_NONCE = 'nonce';
/**
* Key for amp_validate query var array for whether to store the validation results in an amp_validated_url post.
*
* @var string
*/
const VALIDATE_QUERY_VAR_CACHE = 'cache';
/**
* Key for amp_validate query var array for whether to return previously-stored the validation results if an
* amp_validated_url post exists for the URL and it is not stale.
*
* @var string
*/
const VALIDATE_QUERY_VAR_CACHED_IF_FRESH = 'cached_if_fresh';
/**
* Key for amp_validate query var array for whether to omit stylesheets data.
*
* @var string
*/
const VALIDATE_QUERY_VAR_OMIT_STYLESHEETS = 'omit_stylesheets';
/**
* Key for amp_validate query var array to bust the cache.
*
* @var string
*/
const VALIDATE_QUERY_VAR_CACHE_BUST = 'cache_bust';
/**
* Key for amp_validate query var array to force Standard template mode.
*
* @var string
*/
const VALIDATE_QUERY_VAR_FORCE_STANDARD_MODE = 'force_standard_mode';
/**
* Meta capability for validation.
*
* Note that this is mapped to 'manage_options' by default via `AMP_Validation_Manager::map_meta_cap()`. Using a
* meta capability allows a site to customize which users get access to perform validation.
*
* @see AMP_Validation_Manager::map_meta_cap()
* @var string
*/
const VALIDATE_CAPABILITY = 'amp_validate';
/**
* Action name for previewing the status change for invalid markup.
*
* @var string
*/
const MARKUP_STATUS_PREVIEW_ACTION = 'amp_markup_status_preview';
/**
* Query var for passing status preview/update for validation error.
*
* @var string
*/
const VALIDATION_ERROR_TERM_STATUS_QUERY_VAR = 'amp_validation_error_term_status';
/**
* The errors encountered when validating.
*
* @var array[] {
* @type array $error Error data.
* @type bool $sanitized Whether sanitized.
* }
*/
public static $validation_results = [];
/**
* Sources that enqueue (or register) each script.
*
* @var array
*/
public static $enqueued_script_sources = [];
/**
* Sources for script extras that are attached to each dependency.
*
* The keys are the values of the extras being added; the values are an array of the source(s) that caused the extra
* to be added.
*
* @since 1.5
* @var array[]
*/
public static $extra_script_sources = [];
/**
* Sources that enqueue (or register) each style.
*
* @var array
*/
public static $enqueued_style_sources = [];
/**
* Sources for style extras that are attached to each dependency.
*
* The keys are the style handles, and the values are mappings of the inline CSS to the array of sources.
*
* @since 1.5
* @var array[]
*/
public static $extra_style_sources = [];
/**
* Post IDs for posts that have been updated which need to be re-validated.
*
* Keys are post IDs and values are whether the post has been re-validated.
*
* @deprecated In 2.1 the classic editor block validation was removed. This is not removed yet since there is a mini plugin that uses it: https://gist.github.com/westonruter/31ac0e056b8b1278c98f8a9f548fcc1a.
* @var bool[]
*/
public static $posts_pending_frontend_validation = [];
/**
* Current sources gathered for a given hook currently being run.
*
* @see AMP_Validation_Manager::wrap_hook_callbacks()
* @see AMP_Validation_Manager::decorate_filter_source()
* @var array[]
*/
protected static $current_hook_source_stack = [];
/**
* Index for where block appears in a post's content.
*
* @var int
*/
protected static $block_content_index = 0;
/**
* Hook source stack.
*
* This has to be public for the sake of PHP 5.3.
*
* @since 0.7
* @var array[]
*/
public static $hook_source_stack = [];
/**
* Original render_callbacks for blocks at the time of wrapping.
*
* Keys are block names, values are the render_callback callables.
*
* @see AMP_Validation_Manager::wrap_block_callbacks()
* @var array<string, callable>
*/
protected static $original_block_render_callbacks = [];
/**
* Collection of backtraces for when wp_editor() was called.
*
* @var array
*/
protected static $wp_editor_sources = [];
/**
* Whether a validate request is being performed.
*
* When responding to a request to validate a URL, instead of an HTML document being returned, a JSON document is
* returned with any errors that were encountered during validation.
*
* @see AMP_Validation_Manager::get_validate_response_data()
*
* @var bool
*/
protected static $is_validate_request = false;
/**
* Overrides for validation errors.
*
* @var array
*/
public static $validation_error_status_overrides = [];
/**
* Whether the admin bar item was added for AMP.
*
* @var bool
*/
protected static $amp_admin_bar_item_added = false;
/**
* Get dev tools user access service.
*
* @return UserAccess
*/
private static function get_dev_tools_user_access() {
$service = Services::get( 'dev_tools.user_access' );
return $service;
}
/**
* Initialize.
*
* @return void
*/
public static function init() {
add_filter( 'map_meta_cap', [ __CLASS__, 'map_meta_cap' ], 100, 2 );
AMP_Validated_URL_Post_Type::register();
AMP_Validation_Error_Taxonomy::register();
add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_validation' ] );
add_action( 'admin_bar_menu', [ __CLASS__, 'add_admin_bar_menu_items' ], 101 );
add_action( 'wp', [ __CLASS__, 'maybe_fail_validate_request' ] );
add_action( 'wp', [ __CLASS__, 'maybe_send_cached_validate_response' ], 20 );
add_action( 'wp', [ __CLASS__, 'override_validation_error_statuses' ] );
// Allow query parameter to force a response to be served with Standard mode (AMP-first). This query parameter
// is only honored when doing a validation request or when the user is able to do validation. This is used as
// part of Site Scanning in order to determine if the primary theme is suitable for serving AMP.
if ( ! amp_is_canonical() ) {
$filter_hooks = [
'default_option_' . AMP_Options_Manager::OPTION_NAME,
'option_' . AMP_Options_Manager::OPTION_NAME,
];
foreach ( $filter_hooks as $filter_hook ) {
add_filter( $filter_hook, [ __CLASS__, 'filter_options_when_force_standard_mode_request' ] );
}
}
}
/**
* Filter AMP options to set Standard template mode if it is an AMP-override request.
*
* @param array|false $options Options.
* @return array Filtered options.
*/
public static function filter_options_when_force_standard_mode_request( $options ) {
if ( ! $options ) {
$options = [];
}
if (
self::is_validate_request()
&&
self::get_validate_request_args()[ self::VALIDATE_QUERY_VAR_FORCE_STANDARD_MODE ]
) {
$options[ Option::THEME_SUPPORT ] = AMP_Theme_Support::STANDARD_MODE_SLUG;
$options[ Option::ALL_TEMPLATES_SUPPORTED ] = true;
}
return $options;
}
/**
* Determine if a post supports AMP validation.
*
* @since 1.2
*
* @param WP_Post|int $post Post.
* @return bool Whether post supports AMP validation.
*/
public static function post_supports_validation( $post ) {
$post = get_post( $post );
if ( ! $post ) {
return false;
}
return (
// Skip if the post type is not viewable on the frontend, since we need a permalink to validate.
in_array( $post->post_type, AMP_Post_Type_Support::get_eligible_post_types(), true )
&&
! wp_is_post_autosave( $post )
&&
! wp_is_post_revision( $post )
&&
'auto-draft' !== $post->post_status
&&
'trash' !== $post->post_status
&&
amp_is_post_supported( $post )
);
}
/**
* Return whether sanitization is initially accepted (by default) for newly encountered validation errors.
*
* To reject all new validation errors by default, a filter can be used like so:
*
* add_filter( 'amp_validation_error_default_sanitized', '__return_false' );
*
* Whether or not a validation error is then actually sanitized is the ultimately determined by the
* `amp_validation_error_sanitized` filter.
*
* @since 1.0
* @see AMP_Validation_Error_Taxonomy::is_validation_error_sanitized()
* @see AMP_Validation_Error_Taxonomy::get_validation_error_sanitization()
*
* @param array $error Optional. Validation error. Will query the general status if no error provided.
* @return bool Whether sanitization is forcibly accepted.
*/
public static function is_sanitization_auto_accepted( $error = null ) {
if ( $error && amp_is_canonical() ) {
// Excessive CSS on AMP-first sites must not be removed by default since removing CSS can severely break a site.
$accepted = AMP_Style_Sanitizer::STYLESHEET_TOO_LONG !== $error['code'];
} else {
$accepted = true;
}
/**
* Filters whether sanitization is accepted for a newly-encountered validation error .
*
* This only applies to validation errors that have not been encountered before. To override the sanitization
* status of existing validation errors, use the `amp_validation_error_sanitized` filter.
*
* @since 1.4
* @see AMP_Validation_Error_Taxonomy::get_validation_error_sanitization()
*
* @param bool $accepted Default accepted.
* @param array|null $error Validation error. May be null when asking if accepting sanitization is enabled by default.
*/
return apply_filters( 'amp_validation_error_default_sanitized', $accepted, $error );
}
/**
* Add menu items to admin bar for AMP.
*
* When on a non-AMP response (transitional mode), then the admin bar item should include:
* - Icon: LINK SYMBOL when AMP not known to be invalid and sanitization is not forced, or CROSS MARK when AMP is known to be valid.
* - Parent admin item and first submenu item: link to AMP version.
* - Second submenu item: link to validate the URL.
*
* When on transitional AMP response:
* - Icon: CHECK MARK if no unaccepted validation errors on page, or WARNING SIGN if there are unaccepted validation errors which are being forcibly sanitized.
* Otherwise, if there are unsanitized validation errors then a redirect to the non-AMP version will be done.
* - Parent admin item and first submenu item: link to non-AMP version.
* - Second submenu item: link to validate the URL.
*
* When on AMP-first response:
* - Icon: CHECK MARK if no unaccepted validation errors on page, or WARNING SIGN if there are unaccepted validation errors.
* - Parent admin and first submenu item: link to validate the URL.
*
* @see AMP_Validation_Manager::finalize_validation() Where the emoji is updated.
* @see amp_add_admin_bar_view_link() Where an admin bar item may have been added already for Reader/Transitional modes.
*
* @param WP_Admin_Bar $wp_admin_bar Admin bar.
*/
public static function add_admin_bar_menu_items( $wp_admin_bar ) {
if ( is_admin() || ! self::get_dev_tools_user_access()->is_user_enabled() || ! amp_is_available() ) {
self::$amp_admin_bar_item_added = false;
return;
}
$is_amp_request = amp_is_request();
$current_url = remove_query_arg(
array_merge( wp_removable_query_args(), [ QueryVar::NOAMP ] ),
amp_get_current_url()
);
if ( amp_is_canonical() ) {
$amp_url = $current_url;
$non_amp_url = add_query_arg(
QueryVar::NOAMP,
QueryVar::NOAMP_AVAILABLE,
$current_url
);
} elseif ( $is_amp_request ) {
$amp_url = $current_url;
$non_amp_url = add_query_arg(
QueryVar::NOAMP,
QueryVar::NOAMP_MOBILE,
amp_remove_paired_endpoint( $current_url )
);
} else {
$amp_url = amp_add_paired_endpoint( $current_url );
$non_amp_url = $current_url;
}
$validate_url = AMP_Validated_URL_Post_Type::get_recheck_url( AMP_Validated_URL_Post_Type::get_invalid_url_post( $amp_url ) ?: $amp_url );
// Construct the parent admin bar item.
if ( $is_amp_request ) {
$icon = Icon::valid(); // This will get overridden in AMP_Validation_Manager::finalize_validation() if there are unaccepted errors.
$href = $validate_url;
} else {
$icon = Icon::link();
$href = $amp_url;
}
$icon_html = $icon->to_html(
[
'id' => 'amp-admin-bar-item-status-icon',
'class' => 'ab-icon',
]
);
$validate_url_title = __( 'Validate URL', 'amp' );
$parent = [
'id' => 'amp',
'title' => sprintf(
'%s %s',
$icon_html,
esc_html__( 'AMP', 'amp' )
),
'href' => esc_url( $href ),
'meta' => [
'title' => esc_attr( $is_amp_request ? $validate_url_title : __( 'View AMP version', 'amp' ) ),
],
];
// Construct admin bar item for validation.
$validate_item = [
'parent' => 'amp',
'id' => 'amp-validity',
'title' => esc_html( $validate_url_title ),
'href' => esc_url( $validate_url ),
];
// Construct admin bar item to link to AMP version or non-AMP version.
$wp_admin_bar->remove_node( 'amp-view' ); // Remove so we can re-add in the right position.
$link_item = [
'parent' => 'amp',
'id' => 'amp-view',
'href' => esc_url( $is_amp_request ? $non_amp_url : $amp_url ),
];
if ( amp_is_canonical() ) {
$link_item['title'] = esc_html__( 'View with AMP disabled', 'amp' );
} else {
$link_item['title'] = esc_html( $is_amp_request ? __( 'View non-AMP version', 'amp' ) : __( 'View AMP version', 'amp' ) );
}
// Add top-level menu item. Note that this will correctly merge/amend any existing AMP nav menu item added in amp_add_admin_bar_view_link().
$wp_admin_bar->add_node( $parent );
if ( $is_amp_request ) {
$wp_admin_bar->add_node( $validate_item );
$wp_admin_bar->add_node( $link_item );
} else {
$wp_admin_bar->add_node( $link_item );
$wp_admin_bar->add_node( $validate_item );
}
// Add settings link to admin bar.
if ( current_user_can( 'manage_options' ) ) {
$wp_admin_bar->add_node(
[
'parent' => 'amp',
'id' => 'amp-settings',
'title' => esc_html__( 'Settings', 'amp' ),
'href' => esc_url( admin_url( add_query_arg( 'page', AMP_Options_Manager::OPTION_NAME, 'admin.php' ) ) ),
]
);
}
self::$amp_admin_bar_item_added = true;
}
/**
* Override validation error statuses (when requested).
*
* When a query var is present along with the required nonce, override the status of the invalid markup
* as requested.
*
* @since 1.5.0
*/
public static function override_validation_error_statuses() {
$override_validation_error_statuses = (
isset( $_REQUEST['preview'] )
&&
! empty( $_REQUEST[ AMP_Validated_URL_Post_Type::VALIDATION_ERRORS_INPUT_KEY ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
&&
is_array( $_REQUEST[ AMP_Validated_URL_Post_Type::VALIDATION_ERRORS_INPUT_KEY ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
);
if ( ! $override_validation_error_statuses ) {
return;
}
if ( ! isset( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), self::MARKUP_STATUS_PREVIEW_ACTION ) ) {
wp_die(
esc_html__( 'Preview link expired. Please try again.', 'amp' ),
esc_html__( 'Error', 'amp' ),
[ 'response' => 401 ]
);
}
/*
* This can't just easily add an amp_validation_error_sanitized filter because the the filter_sanitizer_args() method
* currently needs to obtain the list of overrides to create a parsed_cache_variant.
*/
foreach ( $_REQUEST[ AMP_Validated_URL_Post_Type::VALIDATION_ERRORS_INPUT_KEY ] as $slug => $data ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! isset( $data[ self::VALIDATION_ERROR_TERM_STATUS_QUERY_VAR ] ) ) {
continue;
}
$slug = sanitize_key( $slug );
$status = (int) $data[ self::VALIDATION_ERROR_TERM_STATUS_QUERY_VAR ];
self::$validation_error_status_overrides[ $slug ] = $status;
ksort( self::$validation_error_status_overrides );
}
}
/**
* Short-circuit validation requests which are for URLs that are not AMP pages.
*
* @since 2.1
*/
public static function maybe_fail_validate_request() {
if ( ! self::is_validate_request() || amp_is_request() ) {
return;
}
if ( ! amp_is_available() ) {
$code = 'AMP_NOT_AVAILABLE';
$message = __( 'The requested URL is not an AMP page. AMP may have been disabled for the URL. If so, you can forget the Validated URL.', 'amp' );
} else {
$code = 'AMP_NOT_REQUESTED';
$message = __( 'The requested URL is not an AMP page.', 'amp' );
}
wp_send_json( compact( 'code', 'message' ), 400 );
}
/**
* Whether a validate request is being performed.
*
* When responding to a request to validate a URL, instead of an HTML document being returned, a JSON document is
* returned with any errors that were encountered during validation.
*
* @see AMP_Validation_Manager::get_validate_response_data()
*
* @return bool
*/
public static function is_validate_request() {
return self::$is_validate_request;
}
/**
* Get validate request args.
*
* @return array {
* Args.
*
* @type string|null $nonce None to authorize validate request or null if none was supplied.
* @type bool $cache Whether to store results in amp_validated_url post.
* @type bool $cached_if_fresh Whether to return previously-stored results if not stale.
* @type bool $omit_stylesheets Whether to omit stylesheet data in the validate response.
* }
*/
private static function get_validate_request_args() {
$defaults = [
self::VALIDATE_QUERY_VAR_NONCE => null,
self::VALIDATE_QUERY_VAR_CACHE => false,
self::VALIDATE_QUERY_VAR_CACHED_IF_FRESH => false,
self::VALIDATE_QUERY_VAR_OMIT_STYLESHEETS => false,
self::VALIDATE_QUERY_VAR_FORCE_STANDARD_MODE => false,
];
if ( ! isset( $_GET[ self::VALIDATE_QUERY_VAR ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $defaults;
}
$unsanitized_values = $_GET[ self::VALIDATE_QUERY_VAR ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( is_string( $unsanitized_values ) ) {
$unsanitized_values = [
self::VALIDATE_QUERY_VAR_NONCE => $unsanitized_values,
];
} elseif ( ! is_array( $unsanitized_values ) ) {
return $defaults;
}
$args = $defaults;
foreach ( $unsanitized_values as $key => $unsanitized_value ) {
switch ( $key ) {
case self::VALIDATE_QUERY_VAR_NONCE:
$args[ $key ] = sanitize_key( $unsanitized_value );
break;
default:
$args[ $key ] = rest_sanitize_boolean( $unsanitized_value );
}
}
return $args;
}
/**
* Initialize a validate request.
*
* This function is called as early as possible, at the plugins_loaded action, to see if the current request is to
* validate the response. If the validate query arg is absent, then this does nothing. If the query arg is present,
* but the value is not a valid auth key, then wp_send_json() is invoked to short-circuit with a failure. Otherwise,
* the static $is_validate_request variable is set to true.
*
* @since 1.5
*/
public static function init_validate_request() {
$should_validate_response = self::should_validate_response();
if ( true === $should_validate_response ) {
self::add_validation_error_sourcing();
self::$is_validate_request = true;
if ( '1' === (string) ini_get( 'display_errors' ) ) {
// Suppress the display of fatal errors that may arise during validation so that they will not be counted
// as actual validation errors.
ini_set( 'display_errors', 0 ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted
}
} else {
self::$is_validate_request = false;
// Short-circuit validation requests that are unauthorized.
if ( $should_validate_response instanceof WP_Error ) {
wp_send_json(
[
'code' => $should_validate_response->get_error_code(),
'message' => $should_validate_response->get_error_message(),
],
401
);
}
}
}
/**
* Add hooks for doing determining sources for validation errors during preprocessing/sanitizing.
*/
public static function add_validation_error_sourcing() {
add_action( 'wp', [ __CLASS__, 'wrap_widget_callbacks' ] );
$int_min = defined( 'PHP_INT_MIN' ) ? PHP_INT_MIN : ~PHP_INT_MAX; // phpcs:ignore PHPCompatibility.Constants.NewConstants.php_int_minFound
add_filter( 'register_block_type_args', [ __CLASS__, 'wrap_block_callbacks' ], $int_min );
add_action( 'all', [ __CLASS__, 'wrap_hook_callbacks' ] );
$wrapped_filters = [ 'the_content', 'the_excerpt' ];
foreach ( $wrapped_filters as $wrapped_filter ) {
add_filter( $wrapped_filter, [ __CLASS__, 'decorate_filter_source' ], PHP_INT_MAX );
}
add_filter( 'do_shortcode_tag', [ __CLASS__, 'decorate_shortcode_source' ], PHP_INT_MAX, 2 );
add_filter( 'embed_oembed_html', [ __CLASS__, 'decorate_embed_source' ], PHP_INT_MAX, 3 );
// The `WP_Block_Type_Registry` class was added in WordPress 5.0.0. Because of that it sometimes caused issues
// on the AMP Validated URL screen when on WordPress 4.9.
if ( class_exists( 'WP_Block_Type_Registry' ) ) {
add_filter(
'the_content',
[
__CLASS__,
'add_block_source_comments',
],
8
); // The do_blocks() function runs at priority 9.
}
add_filter( 'the_editor', [ __CLASS__, 'filter_the_editor_to_detect_sources' ] );
}
/**
* Filter `the_editor` to detect the theme/plugin responsible for calling it `wp_editor()`.
*
* @since 2.2
* @see wp_editor()
*
* @param string $output Editor's HTML markup.
* @return string Editor's HTML markup (unchanged).
*/
public static function filter_the_editor_to_detect_sources( $output ) {
$file_reflection = Services::get( 'dev_tools.file_reflection' );
// Find the first plugin/theme in the call stack.
$backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Only way to find theme/plugin responsible for calling.
foreach ( $backtrace as $call ) {
if (
! empty( $call['function'] )
&&
! empty( $call['file'] )
&&
'wp_editor' === $call['function']
) {
$source = $file_reflection->get_file_source( $call['file'] );
if ( $source ) {
self::$wp_editor_sources[] = $source;
}
break;
}
}
return $output;
}
/**
* Handle save_post action to queue re-validation of the post on the frontend.
*
* This is intended to only apply to post edits made in the classic editor.
*
* @deprecated In 2.1 the classic editor block validation was removed.
* @codeCoverageIgnore
*/
public static function handle_save_post_prompting_validation() {
_deprecated_function( __METHOD__, '2.1' );
}
/**
* Validate the posts pending frontend validation.
*
* @see AMP_Validation_Manager::handle_save_post_prompting_validation()
*
* @deprecated In 2.1 the classic editor block validation was removed.
* @codeCoverageIgnore
*/
public static function validate_queued_posts_on_frontend() {
_deprecated_function( __METHOD__, '2.1' );
}
/**
* Map the amp_validate meta capability to the primitive manage_options capability.
*
* Using a meta capability allows a site to customize which users get access to perform validation.
*
* @param string[] $caps Array of the user's capabilities.
* @param string $cap Capability name.
* @return string[] Filtered primitive capabilities.
*/
public static function map_meta_cap( $caps, $cap ) {
if ( self::VALIDATE_CAPABILITY === $cap ) {
// Note that $caps most likely only contains a single item anyway, but only swapping out the one meta
// capability with the primitive capability allows a site to add additional required capabilities.
$position = array_search( $cap, $caps, true );
if ( false !== $position ) {
$caps[ $position ] = 'manage_options';
}
}
return $caps;
}
/**
* Whether the user has the required capability to validate.
*
* Checks for permissions before validating.
*
* @param int|WP_User|null $user User to check for the capability. If null, the current user is used.
* @return boolean $has_cap Whether the current user has the capability.
*/
public static function has_cap( $user = null ) {
if ( null === $user ) {
$user = wp_get_current_user();
}
return user_can( $user, self::VALIDATE_CAPABILITY );
}
/**
* Add validation error.
*
* @param array $error Error info, especially code.
* @param array $data Additional data, including the node.
*
* @return bool Whether the validation error should result in sanitization.
*/
public static function add_validation_error( array $error, array $data = [] ) {
$node = null;
$sources = null;
if ( isset( $data['node'] ) && $data['node'] instanceof DOMNode ) {
$node = $data['node'];
}
if ( self::is_validate_request() ) {
if ( ! empty( $error['sources'] ) ) {
$sources = $error['sources'];
} elseif ( $node ) {
$sources = self::locate_sources( $node );
}
}
unset( $error['sources'] );
if ( ! isset( $error['code'] ) ) {
$error['code'] = 'unknown';
}
/**
* Filters the validation error array.
*
* This allows plugins to add amend additional properties which can help with
* more accurately identifying a validation error beyond the name of the parent
* node and the element's attributes. The $sources are also omitted because
* these are only available during an explicit validation request and so they
* are not suitable for plugins to vary sanitization by. If looking to force a
* validation error to be ignored, use the 'amp_validation_error_sanitized'
* filter instead of attempting to return an empty value with this filter (as
* that is not supported).
*
* @since 1.0
*
* @param array $error Validation error to be printed.
* @param array $context {
* Context data for validation error sanitization.
*
* @type DOMNode $node Node for which the validation error is being reported. May be null.
* }
*/
$error = apply_filters( 'amp_validation_error', $error, compact( 'node' ) );
$sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $error );
$sanitized = (
AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS === $sanitization['status']
||
AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_ACCEPTED_STATUS === $sanitization['status']
);
/*
* Ignore validation errors which are forcibly sanitized by filter. This includes errors accepted via
* AMP_Validation_Error_Taxonomy::accept_validation_errors(), such as the acceptable_errors in core themes.
* This was introduced in <https://github.com/ampproject/amp-wp/pull/1413> to prevent forcibly-sanitized
* validation errors from being reported, to avoid noise and wasted storage. It was inadvertently
* reverted in de7b04b but then restored as part of <https://github.com/ampproject/amp-wp/pull/1413>.
*/
if ( $sanitized && 'with_filter' === $sanitization['forced'] ) {
return true;
}
// Add sources back into the $error for referencing later. @todo It may be cleaner to store sources separately to avoid having to re-remove later during storage.
$error = array_merge( $error, compact( 'sources' ) );
self::$validation_results[] = compact( 'error', 'sanitized' );
return $sanitized;
}
/**
* Reset the stored removed nodes and attributes.
*
* After testing if the markup is valid,
* these static values will remain.
* So reset them in case another test is needed.
*
* @return void
*/
public static function reset_validation_results() {
self::$validation_results = [];
self::$enqueued_style_sources = [];
self::$enqueued_script_sources = [];
self::$extra_script_sources = [];
self::$extra_style_sources = [];
self::$original_block_render_callbacks = [];
self::$wp_editor_sources = [];
}
/**
* Checks the AMP validity of the post content.
*
* If it's not valid AMP, it displays an error message above the 'Classic' editor.
*
* This is essentially a PHP implementation of ampBlockValidation.handleValidationErrorsStateChange() in JS.
*
* @deprecated In 2.1 the classic editor block validation was removed.
* @codeCoverageIgnore
* @return void
*/
public static function print_edit_form_validation_status() {
_deprecated_function( __METHOD__, '2.1' );
}
/**
* Get source start comment.
*
* @param array $source Source data.
* @param bool $is_start Whether the comment is the start or end.
* @return string HTML Comment.
*/
public static function get_source_comment( array $source, $is_start = true ) {
unset( $source['reflection'] );
return sprintf(
'<!--%samp-source-stack %s-->',
$is_start ? '' : '/',
str_replace( '--', '', wp_json_encode( $source ) )
);
}
/**
* Parse source comment.
*
* @param DOMComment $comment Comment.
* @return array|null Parsed source or null if not a source comment.
*/
public static function parse_source_comment( DOMComment $comment ) {
if ( ! preg_match( '#^\s*(?P<closing>/)?amp-source-stack\s+(?P<args>{.+})\s*$#s', $comment->nodeValue, $matches ) ) {
return null;
}
$source = json_decode( $matches['args'], true );
$closing = ! empty( $matches['closing'] );
return compact( 'source', 'closing' );
}
/**
* Recursively determine if a given dependency depends on another.
*
* @since 1.3
*
* @param WP_Dependencies $dependencies Dependencies.
* @param string $current_handle Current handle.
* @param string $dependency_handle Dependency handle.
* @return bool Whether the current handle is a dependency of the dependency handle.
*/
protected static function has_dependency( WP_Dependencies $dependencies, $current_handle, $dependency_handle ) {
if ( $current_handle === $dependency_handle ) {
return true;
}
if ( ! isset( $dependencies->registered[ $current_handle ] ) ) {
return false;
}
foreach ( $dependencies->registered[ $current_handle ]->deps as $handle ) {
if ( self::has_dependency( $dependencies, $handle, $dependency_handle ) ) {
return true;
}
}
return false;
}
/**
* Determine if a script element matches a given script handle.
*
* @param DOMElement $element Element.
* @param string $script_handle Script handle.
* @return bool
*/
protected static function is_matching_script( DOMElement $element, $script_handle ) {
// Use the ID attribute which was added to printed scripts after WP ?.?.
if ( $element->getAttribute( Attribute::ID ) === "{$script_handle}-js" ) {
return true;
}
if ( ! isset( wp_scripts()->registered[ $script_handle ] ) ) {
return false;
}
$script_dependency = wp_scripts()->registered[ $script_handle ];
if ( empty( $script_dependency->src ) ) {
return false;
}
// Script src attribute is haystack because includes protocol and may include query args (like ver).
return false !== strpos(
$element->getAttribute( 'src' ),
preg_replace( '#^https?:(?=//)#', '', $script_dependency->src )
);
}
/**
* Walk back tree to find the open sources.
*
* @todo This method and others for sourcing could be moved to a separate class.
*
* @param DOMNode $node Node to look for.
* @return array[][] {
* The data of the removed sources (theme, plugin, or mu-plugin).
*
* @type string $name The name of the source.
* @type string $type The type of the source.
* }
*/
public static function locate_sources( DOMNode $node ) {
$dom = Document::fromNode( $node );
$comments = $dom->xpath->query( 'preceding::comment()[ starts-with( ., "amp-source-stack" ) or starts-with( ., "/amp-source-stack" ) ]', $node );
$sources = [];
$matches = [];
foreach ( $comments as $comment ) {
$parsed_comment = self::parse_source_comment( $comment );
if ( ! $parsed_comment ) {
continue;
}
if ( $parsed_comment['closing'] ) {
array_pop( $sources );
} else {
$sources[] = $parsed_comment['source'];
}
}
$is_enqueued_link = (
$node instanceof DOMElement
&&
'link' === $node->nodeName
&&
preg_match( '/(?P<handle>.+)-css$/', (string) $node->getAttribute( Attribute::ID ), $matches )
&&
wp_styles()->query( $matches['handle'] )
);
if ( $is_enqueued_link ) {
// Directly enqueued stylesheet.
if ( isset( self::$enqueued_style_sources[ $matches['handle'] ] ) ) {
$sources = array_merge(
self::$enqueued_style_sources[ $matches['handle'] ],
$sources
);
}
// Stylesheet added as a dependency.
foreach ( wp_styles()->done as $style_handle ) {
if ( $matches['handle'] !== $style_handle ) {
continue;
}
foreach (
self::find_done_dependent_handles( wp_styles(), $style_handle, array_keys( self::$enqueued_style_sources ) )
as
$enqueued_style_sources_handle
) {
$sources = array_merge(
array_map(
static function ( $enqueued_style_source ) use ( $style_handle ) {
$enqueued_style_source['dependency_handle'] = $style_handle;
return $enqueued_style_source;
},
self::$enqueued_style_sources[ $enqueued_style_sources_handle ]
),
$sources
);
}
}
}
$is_inline_style = (
$node instanceof DOMElement
&&
'style' === $node->nodeName
&&
$node->firstChild instanceof DOMText
&&
$node->hasAttribute( Attribute::ID )
&&
preg_match( '/^(?P<handle>.+)-inline-css$/', $node->getAttribute( Attribute::ID ), $matches )
&&
wp_styles()->query( $matches['handle'] )
&&
isset( self::$extra_style_sources[ $matches['handle'] ] )
);
if ( $is_inline_style ) {
$text = $node->textContent;
foreach ( self::$extra_style_sources[ $matches['handle'] ] as $css => $extra_sources ) {
if ( false !== strpos( $text, $css ) ) {
$sources = array_merge(
$sources,
$extra_sources
);
}
}
}
if ( $node instanceof DOMElement && 'script' === $node->nodeName ) {
$enqueued_script_handles = array_intersect( wp_scripts()->done, array_keys( self::$enqueued_script_sources ) );
if ( $node->hasAttribute( 'src' ) ) {
// External scripts, directly enqueued.
foreach ( $enqueued_script_handles as $enqueued_script_handle ) {
if ( ! self::is_matching_script( $node, $enqueued_script_handle ) ) {
continue;
}
$sources = array_merge(
self::$enqueued_script_sources[ $enqueued_script_handle ],
$sources
);
break;
}
// External scripts, added as a dependency.
foreach ( wp_scripts()->done as $script_handle ) {
if ( ! self::is_matching_script( $node, $script_handle ) ) {
continue;
}
foreach (
self::find_done_dependent_handles( wp_scripts(), $script_handle, array_keys( self::$enqueued_script_sources ) )
as
$enqueued_script_sources_handle
) {
$sources = array_merge(
array_map(
static function ( $enqueued_script_source ) use ( $script_handle ) {
$enqueued_script_source['dependency_handle'] = $script_handle;
return $enqueued_script_source;
},
self::$enqueued_script_sources[ $enqueued_script_sources_handle ]
),
$sources
);
}
}
} elseif ( $node->firstChild instanceof DOMText ) {
$text = $node->textContent;
$script_handle = null;
$script_type = null;
if (
$node->hasAttribute( Attribute::ID )
&&
preg_match( '/^(.+)-js-(extra|after|before|translations)/', $node->getAttribute( Attribute::ID ), $matches )
) {
$script_handle = $matches[1];
$script_type = $matches[2];
}
if ( 'translations' === $script_type ) {
// Obtain sources for script translations.
if ( isset( self::$enqueued_script_sources[ $script_handle ] ) ) {
$sources = array_merge( $sources, self::$enqueued_script_sources[ $script_handle ] );
}
foreach (
self::find_done_dependent_handles( wp_scripts(), $script_handle, array_keys( self::$enqueued_script_sources ) )
as
$enqueued_script_sources_handle
) {
$sources = array_merge(
array_map(
static function ( $enqueued_script_source ) use ( $script_handle ) {
$enqueued_script_source['dependency_handle'] = $script_handle;
return $enqueued_script_source;
},
self::$enqueued_script_sources[ $enqueued_script_sources_handle ]
),
$sources
);
}
} else {
// Identify the inline script sources.
foreach ( self::$extra_script_sources as $extra_data => $extra_sources ) {
if ( false === strpos( $text, $extra_data ) ) {
continue;
}
$has_non_core = false;
foreach ( $extra_sources as $extra_source ) {
if ( isset( $extra_source['type'] ) && 'core' !== $extra_source['type'] ) {
$has_non_core = true;
break;
}
}
if ( $has_non_core ) {
$sources = array_merge(
$sources,
$extra_sources
);
} else {
if ( isset( self::$enqueued_script_sources[ $script_handle ] ) ) {
$sources = array_merge( $sources, self::$enqueued_script_sources[ $script_handle ] );
}
foreach (
self::find_done_dependent_handles( wp_scripts(), $script_handle, array_keys( self::$enqueued_script_sources ) )
as
$enqueued_script_sources_handle
) {
$sources = array_merge(
array_map(
static function ( $enqueued_script_source ) use ( $script_handle ) {
$enqueued_script_source['dependency_handle'] = $script_handle;
return $enqueued_script_source;
},
self::$enqueued_script_sources[ $enqueued_script_sources_handle ]
),
$sources
);
}
}
}
}
// Add indirect sources for inline scripts added by wp_editor().
foreach ( $sources as $source ) {
if ( isset( $source['function'] ) && '_WP_Editors::editor_js' === $source['function'] ) {
$sources = array_merge( $sources, self::$wp_editor_sources );
}
}
}
}
$sources = array_values( array_unique( $sources, SORT_REGULAR ) );
return $sources;
}
/**
* Find dependent handles that have been printed.
*
* @param WP_Dependencies $dependencies Dependencies.
* @param string $handle Handle.
* @param string[] $enqueued_handles Enqueued handles.
* @return string[] Found handles.
*/
private static function find_done_dependent_handles( WP_Dependencies $dependencies, $handle, $enqueued_handles ) {
$dependent_handles = [];
foreach ( $enqueued_handles as $enqueued_handle ) {
if (
$enqueued_handle !== $handle
&&
$dependencies->query( $enqueued_handle, 'done' )
&&
self::has_dependency( $dependencies, $enqueued_handle, $handle )
) {
$dependent_handles[] = $enqueued_handle;
}
}
return $dependent_handles;
}
/**
* Add block source comments.
*
* @param string $content Content prior to blocks being processed.
* @return string Content with source comments added.
*/
public static function add_block_source_comments( $content ) {
self::$block_content_index = 0;
$start_block_pattern = implode(
'',
[
'#<!--\s+',
'(?P<closing>/)?',
'wp:(?P<name>\S+)',
'(?:\s+(?P<attributes>\{.*?\}))?',
'\s+(?P<self_closing>\/)?',
'-->#s',
]
);
return preg_replace_callback(
$start_block_pattern,
[ __CLASS__, 'handle_block_source_comment_replacement' ],
$content
);
}
/**
* Handle block source comment replacement.
*
* @see \AMP_Validation_Manager::add_block_source_comments()
*
* @param array $matches Matches.
*
* @return string Replaced.
*/
protected static function handle_block_source_comment_replacement( $matches ) {
$replaced = $matches[0];
// Obtain source information for block.
$source = [
'block_name' => $matches['name'],
'post_id' => get_the_ID(),
];
if ( empty( $matches['closing'] ) ) {
$source['block_content_index'] = self::$block_content_index;
++self::$block_content_index;
}
// Make implicit core namespace explicit.
$is_implicit_core_namespace = ( false === strpos( $source['block_name'], '/' ) );
$source['block_name'] = $is_implicit_core_namespace ? 'core/' . $source['block_name'] : $source['block_name'];
if ( ! empty( $matches['attributes'] ) ) {
$source['block_attrs'] = json_decode( $matches['attributes'] );
}
$block_type = WP_Block_Type_Registry::get_instance()->get_registered( $source['block_name'] );
if ( $block_type && $block_type->is_dynamic() ) {
$render_callback = $block_type->render_callback;
// Access the underlying callback which was wrapped by ::wrap_block_callbacks() below.
while ( $render_callback instanceof AMP_Validation_Callback_Wrapper ) {
$render_callback = $render_callback->get_callback_function();
}
$callback_reflection = Services::get( 'dev_tools.callback_reflection' );
$callback_source = $callback_reflection->get_source( $render_callback );
// Handle special case to undo the wrapping that Gutenberg does in gutenberg_inject_default_block_context().
if (
$callback_source
&&
'plugin' === $callback_source['type']
&&
'gutenberg' === $callback_source['name']
&&
array_key_exists( $source['block_name'], self::$original_block_render_callbacks )
) {
$callback_source = $callback_reflection->get_source( self::$original_block_render_callbacks[ $source['block_name'] ] );
}
if ( $callback_source ) {
$source = array_merge(
$source,
$callback_source
);
}
}
if ( ! empty( $matches['closing'] ) ) {
$replaced .= self::get_source_comment( $source, false );
} else {
$replaced = self::get_source_comment( $source, true ) . $replaced;
if ( ! empty( $matches['self_closing'] ) ) {
unset( $source['block_content_index'] );
$replaced .= self::get_source_comment( $source, false );
}
}
return $replaced;
}
/**
* Wrap callbacks for registered blocks to keep track of queued assets and the source for anything printed for validation.
*
* @param array $args Array of arguments for registering a block type.
*
* @return array Array of arguments for registering a block type.
*/
public static function wrap_block_callbacks( $args ) {
if ( ! isset( $args['render_callback'] ) || ! is_callable( $args['render_callback'] ) ) {
return $args;
}
$callback_reflection = Services::get( 'dev_tools.callback_reflection' );
$source = $callback_reflection->get_source( $args['render_callback'] );
if ( ! $source ) {
return $args;
}
unset( $source['reflection'] ); // Omit from stored source.
$original_function = $args['render_callback'];
if ( isset( $args['name'] ) ) {
self::$original_block_render_callbacks[ $args['name'] ] = $original_function;
}
$args['render_callback'] = new AMP_Validation_Callback_Wrapper(
[
'function' => $original_function,
'source' => $source,
'accepted_args' => 3, // The three args passed to render_callback are $attributes, $content, and $block.
]
);
return $args;
}
/**
* Wrap callbacks for registered widgets to keep track of queued assets and the source for anything printed for validation.
*
* @return void
* @global array $wp_registered_widgets
*/
public static function wrap_widget_callbacks() {
global $wp_registered_widgets;
$callback_reflection = Services::get( 'dev_tools.callback_reflection' );
foreach ( $wp_registered_widgets as $widget_id => &$registered_widget ) {
$source = $callback_reflection->get_source( $registered_widget['callback'] );
if ( ! $source ) {
continue;
}
$source['widget_id'] = $widget_id;
unset( $source['reflection'] ); // Omit from stored source.
$function = $registered_widget['callback'];
$accepted_args = 2; // For the $instance and $args arguments.
$callback = compact( 'function', 'accepted_args', 'source' );
$registered_widget['callback'] = new AMP_Validation_Callback_Wrapper( $callback );
}
}
/**
* Wrap filter/action callback functions for a given hook.
*
* Wrapped callback functions are reset to their original functions after invocation.
* This runs at the 'all' action. The shutdown hook is excluded.
*
* @global WP_Hook[] $wp_filter
* @param string $hook Hook name for action or filter.
* @return void
*/
public static function wrap_hook_callbacks( $hook ) {
global $wp_filter;
if ( ! isset( $wp_filter[ $hook ] ) || 'shutdown' === $hook ) {
return;
}
$callback_reflection = Services::get( 'dev_tools.callback_reflection' );
self::$current_hook_source_stack[ $hook ] = [];
foreach ( $wp_filter[ $hook ]->callbacks as $priority => &$callbacks ) {
foreach ( $callbacks as &$callback ) {
$source = $callback_reflection->get_source( $callback['function'] );
if ( ! $source ) {
continue;
}
// Skip considering ourselves.
if ( 'AMP_Validation_Manager::add_block_source_comments' === $source['function'] ) {
continue;
}
$indirect_sources = [];
if ( '_WP_Editors::enqueue_scripts' === $source['function'] ) {
$indirect_sources = self::$wp_editor_sources;
}
/**
* Reflection.
*
* @var ReflectionFunction|ReflectionMethod $reflection
*/
$reflection = $source['reflection'];
unset( $source['reflection'] ); // Omit from stored source.
// Add hook to stack for decorate_filter_source to read from.
self::$current_hook_source_stack[ $hook ][] = $source;
/*
* Wrapped callbacks cause PHP warnings when the wrapped function has arguments passed by reference.
* We have a special case to support functions that have the first argument passed by reference, namely
* wp_default_scripts() and wp_default_styles(). But other configurations are bypassed.
*/
$passed_by_ref = self::has_parameters_passed_by_reference( $reflection );
if ( $passed_by_ref > 1 ) {
continue;
}
$source['hook'] = $hook;
$source['priority'] = $priority;
$original_function = $callback['function'];
$wrapped_callback = new AMP_Validation_Callback_Wrapper(
array_merge(
$callback,
compact( 'priority', 'source', 'indirect_sources' )
),
static function () use ( &$callback, $original_function ) {
// Restore the original callback function in case other plugins are introspecting filters.
// This logic runs immediately before the original function is actually invoked.
$callback['function'] = $original_function;
}
);
if ( 1 === $passed_by_ref ) {
$callback['function'] = [ $wrapped_callback, 'invoke_with_first_ref_arg' ];
} else {
$callback['function'] = $wrapped_callback;
}
}
}
}
/**
* Determine whether the given reflection method/function has params passed by reference.
*
* @since 0.7
* @param ReflectionFunction|ReflectionMethod $reflection Reflection.
* @return int Whether there are parameters passed by reference, where 0 means none were passed, 1 means the first was passed, and 2 means some other configuration.
*/
protected static function has_parameters_passed_by_reference( $reflection ) {
$status = 0;
foreach ( $reflection->getParameters() as $i => $parameter ) {
if ( $parameter->isPassedByReference() ) {
if ( 0 === $i ) {
$status = 1;
} else {
$status = 2;
break;
}
}
}
return $status;
}
/**
* Filters the output created by a shortcode callback.
*
* @since 0.7
*
* @param string $output Shortcode output.
* @param string $tag Shortcode name.
* @return string Output.
* @global array $shortcode_tags
*/
public static function decorate_shortcode_source( $output, $tag ) {
global $shortcode_tags;
if ( ! isset( $shortcode_tags[ $tag ] ) ) {
return $output;
}
$callback_reflection = Services::get( 'dev_tools.callback_reflection' );
$source = $callback_reflection->get_source( $shortcode_tags[ $tag ] );
if ( empty( $source ) ) {
return $output;
}
$source['shortcode'] = $tag;
$output = implode(
'',
[
self::get_source_comment( $source, true ),
$output,
self::get_source_comment( $source, false ),
]
);
return $output;
}
/**
* Filters the output created by embeds.
*
* @since 1.0
*
* @param string $output Embed output.
* @param string $url URL.
* @param array $attr Attributes.
* @return string Output.
*/
public static function decorate_embed_source( $output, $url, $attr ) {
$source = [
'embed' => $url,
'attr' => $attr,
];
return implode(
'',
[
self::get_source_comment( $source, true ),
trim( $output ),
self::get_source_comment( $source, false ),
]
);
}
/**
* Wraps output of a filter to add source stack comments.
*
* @todo Duplicate with AMP_Validation_Manager::wrap_buffer_with_source_comments()?
* @param string $value Value.
* @return string Value wrapped in source comments.
*/
public static function decorate_filter_source( $value ) {
// Abort if the output is not a string and it doesn't contain any HTML tags.
if ( ! is_string( $value ) || ! preg_match( '/<.+?>/s', $value ) ) {
return $value;
}
$post = get_post();
$source = [
'hook' => current_filter(),
'filter' => true,
];
if ( $post ) {
$source['post_id'] = $post->ID;
$source['post_type'] = $post->post_type;
}
if ( isset( self::$current_hook_source_stack[ current_filter() ] ) ) {
$sources = self::$current_hook_source_stack[ current_filter() ];
array_pop( $sources ); // Remove self.
$source['sources'] = $sources;
}
return implode(
'',
[
self::get_source_comment( $source, true ),
$value,
self::get_source_comment( $source, false ),
]
);
}
/**
* Gets the plugin or theme of the callback, if one exists.
*
* @deprecated 2.0.2 Use \AmpProject\AmpWP\DevTools\CallbackReflection::get_source().
* @codeCoverageIgnore
*
* @param string|array|callable $callback The callback for which to get the plugin.
* @return array|null {
* The source data.
*
* @type string $type Source type (core, plugin, mu-plugin, or theme).
* @type string $name Source name.
* @type string $file Relative file path based on the type.
* @type string $function Normalized function name.
* @type ReflectionMethod|ReflectionFunction $reflection Reflection.
* }
*/
public static function get_source( $callback ) {
_deprecated_function(
__METHOD__,
'2.0.2',
'\AmpProject\AmpWP\DevTools\CallbackReflection::get_source'
);
return Services::get( 'dev_tools.callback_reflection' )
->get_source( $callback );
}
/**
* Check whether or not output buffering is currently possible.
*
* This is to guard against a fatal error: "ob_start(): Cannot use output buffering in output buffering display handlers".
*
* @return bool Whether output buffering is allowed.
*/
public static function can_output_buffer() {
// Output buffering for validation can only be done while overall output buffering is being done for the response.
if ( ! AMP_Theme_Support::is_output_buffering() ) {
return false;
}
// Abort when in shutdown since output has finished, when we're likely in the overall output buffering display handler.
if ( did_action( 'shutdown' ) ) {
return false;
}
// Check if any functions in call stack are output buffering display handlers.
$called_functions = [];
$backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Only way to find out if we are in a buffering display handler.
foreach ( $backtrace as $call_stack ) {
if ( '{closure}' === $call_stack['function'] ) { // TODO: This may not be the right format for PHP 8.4.
$called_functions[] = 'Closure::__invoke';
} elseif ( isset( $call_stack['class'] ) ) {
$called_functions[] = sprintf( '%s::%s', $call_stack['class'], $call_stack['function'] );
} else {
$called_functions[] = $call_stack['function'];
}
}
return 0 === count( array_intersect( ob_list_handlers(), $called_functions ) );
}
/**
* Wraps a callback in comments if it outputs markup.
*
* If the sanitizer removes markup,
* this indicates which plugin it was from.
* The call_user_func_array() logic is mainly copied from WP_Hook:apply_filters().
*
* @deprecated No longer used as of 2.2.1.
* @codeCoverageIgnore
*
* @param array $callback {
* The callback data.
*
* @type callable $function
* @type int $accepted_args
* @type array $source
* }
* @return AMP_Validation_Callback_Wrapper $wrapped_callback The callback, wrapped in comments.
*/
public static function wrapped_callback( $callback ) {
return new AMP_Validation_Callback_Wrapper( $callback );
}
/**
* Wrap output buffer with source comments.
*
* A key reason for why this is a method and not a closure is so that
* the can_output_buffer method will be able to identify it by name.
*
* @since 0.7
* @todo Is duplicate of \AMP_Validation_Manager::decorate_filter_source()?
*
* @param string $output Output buffer.
* @return string Output buffer conditionally wrapped with source comments.
*/
public static function wrap_buffer_with_source_comments( $output ) {
if ( empty( self::$hook_source_stack ) ) {
return $output;
}
$source = self::$hook_source_stack[ count( self::$hook_source_stack ) - 1 ];
// Wrap output that contains HTML tags (as opposed to actions that trigger in HTML attributes).
if ( ! empty( $output ) && preg_match( '/<.+?>/s', $output ) ) {
$output = implode(
'',
[
self::get_source_comment( $source, true ),
$output,
self::get_source_comment( $source, false ),
]
);
}
return $output;
}
/**
* Get nonce for performing amp_validate request.
*
* The returned nonce is irrespective of the authenticated user.
*
* @return string Nonce.
*/
public static function get_amp_validate_nonce() {
return wp_hash( self::VALIDATE_QUERY_VAR . wp_nonce_tick(), 'nonce' );
}
/**
* Whether the request is to validate URL for validation errors.
*
* All AMP responses get validated, but when the amp_validate query parameter is present, then the source information
* for each validation error is captured and the validation results are returned as JSON instead of the AMP HTML page.
*
* @return bool|WP_Error Whether to validate. False is returned if it is not a validate request. WP_Error returned
* if unauthenticated, unauthorized, and/or invalid nonce supplied. True returned if
* validate response should be served.
*/
public static function should_validate_response() {
$args = self::get_validate_request_args();
if ( null === $args[ self::VALIDATE_QUERY_VAR_NONCE ] ) {
return false;
}
if ( ! hash_equals( self::get_amp_validate_nonce(), $args[ self::VALIDATE_QUERY_VAR_NONCE ] ) ) {
return new WP_Error(
'http_request_failed',
__( 'Nonce authentication failed.', 'amp' )
);
}
return true;
}
/**
* Get response data for a validate request.
*
* @see AMP_Content_Sanitizer::sanitize_document()
*
* @param array $sanitization_results {
* Results of sanitizing a document, as returned by AMP_Content_Sanitizer::sanitize_document().
*
* @type array $scripts Scripts.
* @type array $stylesheets Stylesheets.
* @type AMP_Base_Sanitizer[] $sanitizers Sanitizers.
* }
* @return array Validate response data.
*/
public static function get_validate_response_data( $sanitization_results ) {
$data = [
'results' => self::$validation_results,
'queried_object' => null,
'url' => amp_get_current_url(),
];
$queried_object = get_queried_object();
if ( $queried_object ) {
$data['queried_object'] = [];
$queried_object_id = get_queried_object_id();
if ( $queried_object_id ) {
$data['queried_object']['id'] = $queried_object_id;
}
if ( $queried_object instanceof WP_Post ) {
$data['queried_object']['type'] = 'post';
} elseif ( $queried_object instanceof WP_Term ) {
$data['queried_object']['type'] = 'term';
} elseif ( $queried_object instanceof WP_User ) {
$data['queried_object']['type'] = 'user';
} elseif ( $queried_object instanceof WP_Post_Type ) {
$data['queried_object']['type'] = 'post_type';
}
}
/**
* Sanitizers
*
* @var AMP_Base_Sanitizer[] $sanitizers
*/
$sanitizers = $sanitization_results['sanitizers'];
foreach ( $sanitizers as $class_name => $sanitizer ) {
$sanitizer_data = $sanitizer->get_validate_response_data();
$conflicting_keys = array_intersect( array_keys( $sanitizer_data ), array_keys( $data ) );
if ( ! empty( $conflicting_keys ) ) {
_doing_it_wrong(
esc_html( "$class_name::get_validate_response_data" ),
esc_html( 'Method is returning array with conflicting keys: ' . implode( ', ', $conflicting_keys ) ),
'1.5'
);
} else {
$data = array_merge( $data, $sanitizer_data );
}
}
return $data;
}
/**
* Remove source stack comments which appear inside of script and style tags.
*
* HTML comments that appear inside of script and style elements get parsed as text content. AMP does not allow
* such HTML comments to appear inside of CDATA, resulting in validation errors to be emitted when validating a
* page that happens to have source stack comments output when generating JSON data (e.g. All in One SEO).
* Additionally, when source stack comments are output inside of style elements the result can either be CSS
* parse errors or incorrect stylesheet sizes being reported due to the presence of the source stack comments.
* So to prevent these issues from occurring, the source stack comments need to be removed from the document prior
* to sanitizing.
*
* @since 1.5
*
* @param Document $dom Document.
*/
public static function remove_illegal_source_stack_comments( Document $dom ) {
/**
* Script element.
*
* @var DOMText $text
*/
foreach ( $dom->xpath->query( '//text()[ contains( ., "<!--amp-source-stack" ) ][ parent::script or parent::style ]' ) as $text ) {
$text->nodeValue = preg_replace( '#<!--/?amp-source-stack.*?-->#s', '', $text->nodeValue );
}
}
/**
* Send validate response.
*
* @since 2.2
* @see AMP_Theme_Support::prepare_response()
*
* @param array $sanitization_results Sanitization results.
* @param int $status_code Status code.
* @param array|null $last_error Last error.
* @return string JSON.
*/
public static function send_validate_response( $sanitization_results, $status_code, $last_error ) {
status_header( 200 );
if ( ! headers_sent() ) {
header( 'Content-Type: application/json; charset=utf-8' );
}
$data = [
'http_status_code' => $status_code,
'php_fatal_error' => false,
];
if ( $last_error && in_array( $last_error['type'], [ E_ERROR, E_RECOVERABLE_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_PARSE ], true ) ) {
$data['php_fatal_error'] = $last_error;
}
$data = array_merge( $data, self::get_validate_response_data( $sanitization_results ) );
$args = self::get_validate_request_args();
$data['revalidated'] = true;
if ( $args[ self::VALIDATE_QUERY_VAR_CACHE ] ) {
$validation_errors = wp_list_pluck( $data['results'], 'error' );
$validated_url_post_id = AMP_Validated_URL_Post_Type::store_validation_errors(
$validation_errors,
amp_get_current_url(),
$data
);
if ( is_wp_error( $validated_url_post_id ) ) {
status_header( 500 );
return wp_json_encode(
[
'code' => $validated_url_post_id->get_error_code(),
'message' => $validated_url_post_id->get_error_message(),
]
);
} else {
status_header( 201 );
$data['validated_url_post'] = [
'id' => $validated_url_post_id,
'edit_link' => get_edit_post_link( $validated_url_post_id, 'raw' ),
];
}
}
if ( $args[ self::VALIDATE_QUERY_VAR_OMIT_STYLESHEETS ] ) {
unset( $data['stylesheets'] );
}
$data['url'] = remove_query_arg( self::VALIDATE_QUERY_VAR, $data['url'] );
return wp_json_encode( $data, JSON_UNESCAPED_SLASHES );
}
/**
* Send cached validate response if it is requested and available.
*
* When a validate request is made with the `amp_validate[cached_if_fresh]=true` query parameter, before a page
* begins rendering a check is made for whether there is already an `amp_validated_url` post for the current URL.
* If there is, and the post is not stale, then the previous results are returned without re-rendering page and
* obtaining the validation data.
*/
public static function maybe_send_cached_validate_response() {
if ( ! self::is_validate_request() ) {
return;
}
$args = self::get_validate_request_args();
if ( ! $args[ self::VALIDATE_QUERY_VAR_CACHED_IF_FRESH ] ) {
return;
}
$post = AMP_Validated_URL_Post_Type::get_invalid_url_post( amp_get_current_url() );
if ( ! ( $post instanceof WP_Post ) ) {
return;
}
$staleness = AMP_Validated_URL_Post_Type::get_post_staleness( $post );
if ( count( $staleness ) > 0 ) {
return;
}
$response = [
'http_status_code' => 200, // Note: This is not currently cached in postmeta.
'php_fatal_error' => false,
'results' => [],
'queried_object' => null,
'url' => null,
'revalidated' => false, // Since cached was used.
'validated_url_post' => [
'id' => $post->ID,
'edit_link' => get_edit_post_link( $post->ID, 'raw' ),
],
];
if ( ! $args[ self::VALIDATE_QUERY_VAR_OMIT_STYLESHEETS ] ) {
$stylesheets = get_post_meta( $post->ID, AMP_Validated_URL_Post_Type::STYLESHEETS_POST_META_KEY, true );
if ( $stylesheets ) {
$response['stylesheets'] = json_decode( $stylesheets, true );
}
}
$stored_validation_errors = json_decode( $post->post_content, true );
if ( is_array( $stored_validation_errors ) ) {
$response['results'] = array_map(
static function ( $stored_validation_error ) {
$error = $stored_validation_error['data'];
$sanitized = AMP_Validation_Error_Taxonomy::is_validation_error_sanitized( $error );
return compact( 'error', 'sanitized' );
},
$stored_validation_errors
);
}
$queried_object = get_post_meta( $post->ID, AMP_Validated_URL_Post_Type::QUERIED_OBJECT_POST_META_KEY, true );
if ( $queried_object ) {
$response['queried_object'] = $queried_object;
}
$php_fatal_error = get_post_meta( $post->ID, AMP_Validated_URL_Post_Type::PHP_FATAL_ERROR_POST_META_KEY, true );
if ( $php_fatal_error ) {
$response['php_fatal_error'] = $php_fatal_error;
}
$response['url'] = AMP_Validated_URL_Post_Type::get_url_from_post( $post );
wp_send_json( $response, 200, JSON_UNESCAPED_SLASHES );
}
/**
* Finalize validation.
*
* @see AMP_Validation_Manager::add_admin_bar_menu_items()
*
* @param Document $dom Document.
* @return bool Whether the document should be displayed to the user.
*/
public static function finalize_validation( Document $dom ) {
$total_count = 0;
$kept_count = 0;
$unreviewed_count = 0;
foreach ( self::$validation_results as $validation_result ) {
$sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $validation_result['error'] );
if ( ! ( (int) $sanitization['status'] & AMP_Validation_Error_Taxonomy::ACCEPTED_VALIDATION_ERROR_BIT_MASK ) ) {
++$kept_count;
}
if ( ! ( (int) $sanitization['status'] & AMP_Validation_Error_Taxonomy::ACKNOWLEDGED_VALIDATION_ERROR_BIT_MASK ) ) {
++$unreviewed_count;
}
++$total_count;
}
/*
* Override AMP status in admin bar set in \AMP_Validation_Manager::add_admin_bar_menu_items()
* when there are validation errors which have not been explicitly accepted.
*/
if ( is_admin_bar_showing() && self::$amp_admin_bar_item_added && $total_count > 0 ) {
self::update_admin_bar_item( $dom, $total_count, $kept_count, $unreviewed_count );
}
// If no invalid markup is kept, then the page should definitely be displayed to the user.
if ( 0 === $kept_count ) {
return true;
}
// When overrides are present, go ahead and display to the user.
if ( ! empty( self::$validation_error_status_overrides ) ) {
return true;
}
$sandboxing_level = amp_get_sandboxing_level();
/*
* In AMP-first, documents with invalid AMP markup can still be served. The amp attribute will be omitted in
* order to prevent GSC from complaining about a validation error already surfaced inside of WordPress.
* This is intended to not serve dirty AMP, but rather a non-AMP document (intentionally not valid AMP) that
* contains the AMP runtime and AMP components.
*
* Otherwise, if in Paired AMP then redirect to the non-AMP version if the current user isn't an user who
* can manage validation error statuses (access developer tools) and change the AMP options for the template
* mode. Such users should be able to see kept invalid markup on the AMP page even though it is invalid.
*
* Also, if sandboxing is not set to strict mode, then the page should be displayed to the user.
*/
if ( amp_is_canonical() || ( 1 === $sandboxing_level || 2 === $sandboxing_level ) ) {
return true;
}
// Otherwise, since it is in a paired mode, only allow showing the dirty AMP page if the user is authorized.
// If not, normally the result is redirection to the non-AMP version.
return self::has_cap() || is_customize_preview();
}
/**
* Override AMP status in admin bar set in \AMP_Validation_Manager::add_admin_bar_menu_items()
* when there are validation errors which have not been explicitly accepted.
*
* @param Document $dom Document.
* @param int $total_count Total count of validation errors (more than 0).
* @param int $kept_count Count of validation errors with invalid markup kept.
* @param int $unreviewed_count Count of unreviewed validation errors.
*/
private static function update_admin_bar_item( Document $dom, $total_count, $kept_count, $unreviewed_count ) {
$parent_menu_item = $dom->getElementById( 'wp-admin-bar-amp' );
if ( ! $parent_menu_item instanceof DOMElement ) {
return;
}
$parent_menu_link = $dom->xpath->query( './a[ @href ]', $parent_menu_item )->item( 0 );
$admin_bar_icon = $dom->xpath->query( './span[ @id = "amp-admin-bar-item-status-icon" ]', $parent_menu_link )->item( 0 );
$validate_link = $dom->xpath->query( './/li[ @id = "wp-admin-bar-amp-validity" ]/a[ @href ]', $parent_menu_item )->item( 0 );
if ( ! $parent_menu_link instanceof DOMElement || ! $admin_bar_icon instanceof DOMElement || ! $validate_link instanceof DOMElement ) {
return;
}
/*
* When in Paired AMP, non-administrators accessing the AMP version will get redirected to the non-AMP version
* if there are is kept invalid markup. In Paired AMP, the AMP plugin never intends to advertise the availability
* of dirty AMP pages. However, in AMP-first (Standard mode), there is no non-AMP version to redirect to, so
* kept invalid markup does not cause redirection but rather the `amp` attribute is removed from the AMP page
* to serve an intentionally invalid AMP page with the AMP runtime loaded which is exempted from AMP validation
* (and excluded from being indexed as an AMP page). So this is why the first conditional will only show the
* error icon for kept markup when _not_ AMP-first. This will only be displayed to administrators who are directly
* accessing the AMP version. Otherwise, if there is no kept invalid markup _or_ it is AMP-first, then the AMP
* admin bar item will be updated to show if there are any unreviewed validation errors (regardless of whether
* they are kept or removed).
*/
if ( $kept_count > 0 && ! amp_is_canonical() ) {
$admin_bar_icon->setAttribute( 'class', 'ab-icon amp-icon ' . Icon::INVALID );
} elseif ( $unreviewed_count > 0 || $kept_count > 0 ) {
$admin_bar_icon->setAttribute( 'class', 'ab-icon amp-icon ' . Icon::WARNING );
}
// Update the text of the link to reflect the status of the validation error(s).
$items = [];
if ( $unreviewed_count > 0 ) {
if ( $unreviewed_count === $total_count ) {
/* translators: text is describing validation issue(s) */
$items[] = _n(
'unreviewed',
'all unreviewed',
$unreviewed_count,
'amp'
);
} else {
$items[] = sprintf(
/* translators: %s the total count of unreviewed validation errors */
_n(
'%s unreviewed',
'%s unreviewed',
$unreviewed_count,
'amp'
),
number_format_i18n( $unreviewed_count )
);
}
}
if ( $kept_count > 0 ) {
if ( $kept_count === $total_count ) {
/* translators: text is describing validation issue(s) */
$items[] = _n(
'kept',
'all kept',
$kept_count,
'amp'
);
} else {
$items[] = sprintf(
/* translators: %s the total count of unreviewed validation errors */
_n(
'%s kept',
'%s kept',
$kept_count,
'amp'
),
number_format_i18n( $kept_count )
);
}
}
if ( empty( $items ) ) {
/* translators: text is describing validation issue(s) */
$items[] = _n(
'reviewed',
'all reviewed',
$total_count,
'amp'
);
}
$text = sprintf(
/* translators: %s is total count of validation errors */
_n(
'%s issue:',
'%s issues:',
$total_count,
'amp'
),
number_format_i18n( $total_count )
);
$text .= ' ' . implode( ', ', $items );
$validate_link->appendChild( $dom->createTextNode( ' ' ) );
$small = $dom->createElement( Tag::SMALL );
try {
$small->setAttribute( Attribute::STYLE, 'font-size: smaller' );
} catch ( MaxCssByteCountExceeded $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Making the font size smaller is just a nice-to-have.
}
$small->appendChild( $dom->createTextNode( sprintf( '(%s)', $text ) ) );
$validate_link->appendChild( $small );
}
/**
* Adds the validation callback if front-end validation is needed.
*
* @param array $sanitizers The AMP sanitizers.
* @return array $sanitizers The filtered AMP sanitizers.
*/
public static function filter_sanitizer_args( $sanitizers ) {
foreach ( $sanitizers as &$args ) {
$args['validation_error_callback'] = __CLASS__ . '::add_validation_error';
}
if ( isset( $sanitizers[ AMP_Style_Sanitizer::class ] ) ) {
$sanitizers[ AMP_Style_Sanitizer::class ]['should_locate_sources'] = self::is_validate_request();
$css_validation_errors = [];
foreach ( self::$validation_error_status_overrides as $slug => $status ) {
$term = AMP_Validation_Error_Taxonomy::get_term( $slug );
if ( ! $term ) {
continue;
}
$validation_error = json_decode( $term->description, true );
$is_css_validation_error = (
is_array( $validation_error )
&&
isset( $validation_error['code'] )
&&
in_array( $validation_error['code'], AMP_Style_Sanitizer::get_css_parser_validation_error_codes(), true )
);
if ( $is_css_validation_error ) {
$css_validation_errors[ $slug ] = $status;
}
}
if ( ! empty( $css_validation_errors ) ) {
$sanitizers[ AMP_Style_Sanitizer::class ]['parsed_cache_variant'] = md5( wp_json_encode( $css_validation_errors ) );
}
}
return $sanitizers;
}
/**
* Validate a URL to be validated.
*
* @param string $url URL.
* @return string|WP_Error Validated URL or else error.
*/
private static function validate_validation_url( $url ) {
$validated_url = wp_validate_redirect( $url );
if ( ! $validated_url ) {
return new WP_Error(
'http_request_failed',
/* translators: %s is the URL being redirected to. */
sprintf( __( 'Unable to validate a URL on another site. Attempted to validate: %s', 'amp' ), $url )
);
}
return $validated_url;
}
/**
* Validates a given URL.
*
* The validation errors will be stored in the validation status custom post type,
* as well as in a transient.
*
* @param string $url The URL to validate. This need not include the amp query var.
* @return WP_Error|array {
* Response.
*
* @type array $results Validation results, where each nested array contains an error key and sanitized key.
* @type string $url Final URL that was checked or redirected to.
* @type array $queried_object Queried object, including keys for 'type' and 'id'.
* @type array $stylesheets Stylesheet data.
* @type string $php_fatal_error PHP fatal error which occurred during validation.
* }
*/
public static function validate_url( $url ) {
if ( ! amp_is_canonical() && ! amp_has_paired_endpoint( $url ) ) {
$url = amp_add_paired_endpoint( $url );
}
$added_query_vars = [
self::VALIDATE_QUERY_VAR => [
self::VALIDATE_QUERY_VAR_NONCE => self::get_amp_validate_nonce(),
self::VALIDATE_QUERY_VAR_CACHE_BUST => wp_rand(),
],
];
// Ensure the URL to be validated is on the site.
$validation_url = self::validate_validation_url( $url );
if ( is_wp_error( $validation_url ) ) {
return $validation_url;
}
$validation_url = add_query_arg( $added_query_vars, $validation_url );
$r = null;
/** This filter is documented in wp-includes/class-http.php */
$allowed_redirects = apply_filters( 'http_request_redirection_count', 5 );
for ( $redirect_count = 0; $redirect_count < $allowed_redirects; $redirect_count++ ) {
$r = wp_remote_get(
$validation_url,
[
'cookies' => wp_unslash( $_COOKIE ), // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE -- Pass along cookies so private pages and drafts can be accessed.
'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Increase from default of 5 to give extra time for the plugin to identify the sources for any given validation errors.
/** This filter is documented in wp-includes/class-wp-http-streams.php */
'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
'redirection' => 0, // Because we're in a loop for redirection.
'headers' => [
'Cache-Control' => 'no-cache',
],
]
);
// If the response is not a redirect, then break since $r is all we need.
$response_code = wp_remote_retrieve_response_code( $r );
$location_header = wp_remote_retrieve_header( $r, 'Location' );
$is_redirect = (
$response_code
&&
$response_code > 300 && $response_code < 400
&&
$location_header
);
if ( ! $is_redirect ) {
break;
}
// Ensure absolute URL.
if ( '/' === substr( $location_header, 0, 1 ) ) {
$location_header = preg_replace( '#(^https?://[^/]+)/.*#', '$1', home_url( '/' ) ) . $location_header;
}
// Prevent following a redirect to another site, which won't work for validation anyway.
$validation_url = self::validate_validation_url( $location_header );
if ( is_wp_error( $validation_url ) ) {
return $validation_url;
}
$validation_url = add_query_arg( $added_query_vars, $validation_url );
}
if ( is_wp_error( $r ) ) {
return $r;
}
$response = trim( wp_remote_retrieve_body( $r ) );
if ( wp_remote_retrieve_response_code( $r ) >= 400 ) {
$data = json_decode( $response, true );
return new WP_Error(
is_array( $data ) && isset( $data['code'] ) ? $data['code'] : wp_remote_retrieve_response_code( $r ),
is_array( $data ) && isset( $data['message'] ) ? $data['message'] : wp_remote_retrieve_response_message( $r )
);
}
if ( wp_remote_retrieve_response_code( $r ) >= 300 ) {
return new WP_Error(
'http_request_failed',
__( 'Too many redirects.', 'amp' )
);
}
$url = remove_query_arg(
array_keys( $added_query_vars ),
$validation_url
);
// Strip byte order mark (BOM).
while ( "\xEF\xBB\xBF" === substr( $response, 0, 3 ) ) {
$response = substr( $response, 3 );
}
// Strip any leading whitespace.
$response = ltrim( $response );
// Strip HTML comments that may have been injected at the end of the response (e.g. by a caching plugin).
while ( ! empty( $response ) ) {
$response = rtrim( $response );
$length = strlen( $response );
if ( $length < 3 || '-' !== $response[ $length - 3 ] || '-' !== $response[ $length - 2 ] || '>' !== $response[ $length - 1 ] ) {
break;
}
$start = strrpos( $response, '<!--' );
if ( false === $start ) {
break;
}
$response = substr( $response, 0, $start );
}
if ( '' === $response ) {
return new WP_Error( 'white_screen_of_death' );
}
if ( '{' !== substr( $response, 0, 1 ) ) {
return new WP_Error( 'response_not_json' );
}
$validation = json_decode( $response, true );
if ( json_last_error() || ! isset( $validation['results'] ) || ! is_array( $validation['results'] ) ) {
return new WP_Error( 'malformed_json_validation_errors' );
}
return array_merge(
$validation,
compact( 'url' )
);
}
/**
* Validate URL and store result.
*
* @param string $url URL to validate.
* @param int|WP_Post $post The amp_validated_url post to update. Optional. If empty, then post is looked up by URL.
* @return WP_Error|array {
* Error on failure, or array on success.
*
* @type int $post_id ID for the amp_validated_url post.
* @type array $results Validation results, where each nested array contains an error key and sanitized key.
* @type string $url Final URL that was checked or redirected to.
* @type array $queried_object Queried object, including keys for 'type' and 'id'.
* @type array $stylesheets Stylesheet data.
* @type string $php_fatal_error PHP fatal error which occurred during validation.
* }
*/
public static function validate_url_and_store( $url, $post = null ) {
$validity = self::validate_url( $url );
if ( $validity instanceof WP_Error ) {
return $validity;
}
$args = wp_array_slice_assoc( $validity, [ 'queried_object', 'stylesheets', 'php_fatal_error' ] );
if ( $post ) {
$args['invalid_url_post'] = $post;
}
$result = AMP_Validated_URL_Post_Type::store_validation_errors(
wp_list_pluck( $validity['results'], 'error' ),
$validity['url'],
$args
);
if ( $result instanceof WP_Error ) {
return $result;
}
$validity['post_id'] = $result;
return $validity;
}
/**
* Serialize validation error messages.
*
* In order to safely pass validation error messages through redirects with query parameters, they must be serialized
* with a HMAC for security. The messages contain markup so the HMAC prevents tampering.
*
* @since 1.4.2
* @see AMP_Validation_Manager::unserialize_validation_error_messages()
*
* @param string[] $messages Messages.
* @return string Serialized.
*/
public static function serialize_validation_error_messages( $messages ) {
$encoded_messages = base64_encode( wp_json_encode( array_unique( $messages ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return wp_hash( $encoded_messages . wp_nonce_tick(), 'nonce' ) . ':' . $encoded_messages;
}
/**
* Unserialize validation error messages.
*
* @since 1.4.2
* @see AMP_Validation_Manager::serialize_validation_error_messages()
*
* @param string $serialized Serialized messages.
* @return string[]|null
*/
public static function unserialize_validation_error_messages( $serialized ) {
$parts = explode( ':', $serialized, 2 );
if (
count( $parts ) !== 2
||
! hash_equals(
$parts[0],
wp_hash( $parts[1] . wp_nonce_tick(), 'nonce' )
)
) {
return null;
}
return json_decode( base64_decode( $parts[1] ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
}
/**
* Get error message for a validate URL failure.
*
* @param string $error_code Error code.
* @param string $error_message Error message, typically technical such as from HTTP status text or cURL error message.
* @return string Error message with HTML markup which has had its translated strings passed through wp_kses().
*/
public static function get_validate_url_error_message( $error_code, $error_message = '' ) {
$check_error_log = sprintf(
/* translators: %1$s is link to Debugging in WordPress, %2$s is WP_DEBUG_LOG */
__( 'Please check your server PHP error logs; to do this you may need to <a href="%1$s" target="_blank">enable</a> %2$s.', 'amp' ),
esc_url( 'https://wordpress.org/support/article/debugging-in-wordpress/' ),
'<code>WP_DEBUG_LOG</code>'
);
if ( $error_message ) {
$error_message = rtrim( $error_message, '.' ) . '.';
}
$support_forum_message = sprintf(
/* translators: %1$s: Link to support forum. %2$s: Link to new topic form in support forum. */
__( 'If you are stuck, please search the <a href="%1$s">support forum</a> for possible related topics, or otherwise start a <a href="%2$s">new support topic</a> including the error message, the URL to your site, and your active theme/plugins.', 'amp' ),
esc_url( 'https://wordpress.org/support/plugin/amp/' ),
esc_url( 'https://wordpress.org/support/plugin/amp/#new-topic-0' )
);
$site_health_message = sprintf(
/* translators: %s is link to Site Health */
__( 'Please check your <a href="%s">Site Health</a> to verify it can perform loopback requests.', 'amp' ),
esc_url( admin_url( 'site-health.php' ) )
);
$support_forum_message .= ' ' . sprintf(
/* translators: %s is the URL to Site Health Info. */
__( 'Please include your <a href="%s">Site Health Info</a>.', 'amp' ),
esc_url( admin_url( 'site-health.php?tab=debug' ) )
);
$implode_non_empty_strings_with_spaces_and_sanitize = static function ( $strings ) {
return wp_kses(
implode( ' ', array_filter( $strings ) ),
[
'a' => array_fill_keys( [ 'href', 'target' ], true ),
'code' => [],
]
);
};
switch ( $error_code ) {
case 'http_request_failed':
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
esc_html__( 'Failed to fetch URL to validate.', 'amp' ),
esc_html( $error_message ),
$site_health_message,
$support_forum_message,
]
);
case 'white_screen_of_death':
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
esc_html__( 'Unable to validate URL. A white screen of death was encountered which is likely due to a PHP fatal error.', 'amp' ),
esc_html( $error_message ),
$check_error_log,
$support_forum_message,
]
);
case '404':
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
esc_html__( 'The fetched URL was not found. It may have been deleted. If so, you can trash this.', 'amp' ),
esc_html( $error_message ),
$support_forum_message,
]
);
case '500':
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
esc_html__( 'An internal server error occurred when fetching the URL for validation.', 'amp' ),
esc_html( $error_message ),
$check_error_log,
$support_forum_message,
]
);
case 'fatal_error_during_validation':
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
esc_html__( 'A PHP fatal error occurred while validating the URL. This may indicate either a bug in theme/plugin code or it may be due to an issue in the AMP plugin itself.', 'amp' ),
defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY
? esc_html__( 'The error details appear below.', 'amp' )
/* translators: %s is WP_DEBUG_DISPLAY */
: $check_error_log . ' ' . wp_kses_post( sprintf( __( 'Alternatively, you may enable %s to show the error details below.', 'amp' ), '<code>WP_DEBUG_DISPLAY</code>' ) ),
$support_forum_message,
]
);
case 'response_not_json':
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
esc_html__( 'URL validation failed due to the AMP validation request not returning JSON data. This may be due to a PHP fatal error occurring.', 'amp' ),
esc_html( $error_message ),
$check_error_log,
$support_forum_message,
]
);
case 'malformed_json_validation_errors':
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
esc_html__( 'URL validation failed due to unexpected JSON in AMP validation response.', 'amp' ),
esc_html( $error_message ),
$support_forum_message,
]
);
default:
return $implode_non_empty_strings_with_spaces_and_sanitize(
[
/* translators: %s is error code */
esc_html( sprintf( __( 'URL validation failed. Error code: %s.', 'amp' ), $error_code ) ),
esc_html( $error_message ),
$support_forum_message,
]
);
}
}
/**
* Enqueues the block validation script.
*
* @return void
*/
public static function enqueue_block_validation() {
/*
* The AMP_Validation_Manager::post_supports_validation() method is not being used here because
* a post's status for validation checking can change during the life of the editor, such as when
* the user toggles AMP back on after having turned it off, and then gets the validation
* warnings appearing due to the amp-block-validation having been enqueued already.
*/
if ( ! self::get_dev_tools_user_access()->is_user_enabled() ) {
return;
}
// Only enqueue scripts on the block editor for AMP-enabled posts.
$editor_support = Services::get( 'editor.editor_support' );
if ( ! $editor_support->is_current_screen_block_editor_for_amp_enabled_post_type() ) {
return;
}
$slug = 'amp-block-validation';
$asset_file = AMP__DIR__ . '/assets/js/' . $slug . '.asset.php';
$asset = require $asset_file;
$dependencies = $asset['dependencies'];
$version = $asset['version'];
wp_enqueue_script(
$slug,
amp_get_asset_url( "js/{$slug}.js" ),
$dependencies,
$version,
true
);
wp_enqueue_style(
$slug,
amp_get_asset_url( "css/{$slug}.css" ),
false,
AMP__VERSION
);
wp_styles()->add_data( $slug, 'rtl', 'replace' );
$block_sources = Services::has( 'dev_tools.block_sources' ) ? Services::get( 'dev_tools.block_sources' ) : null;
$plugin_registry = Services::get( 'plugin_registry' );
$plugin_names = array_map(
static function ( $plugin ) {
return isset( $plugin['Name'] ) ? $plugin['Name'] : '';
},
$plugin_registry->get_plugins()
);
$data = [
'HTML_ATTRIBUTE_ERROR_TYPE' => AMP_Validation_Error_Taxonomy::HTML_ATTRIBUTE_ERROR_TYPE,
'HTML_ELEMENT_ERROR_TYPE' => AMP_Validation_Error_Taxonomy::HTML_ELEMENT_ERROR_TYPE,
'JS_ERROR_TYPE' => AMP_Validation_Error_Taxonomy::JS_ERROR_TYPE,
'CSS_ERROR_TYPE' => AMP_Validation_Error_Taxonomy::CSS_ERROR_TYPE,
'VALIDATION_ERROR_NEW_REJECTED_STATUS' => AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_REJECTED_STATUS,
'VALIDATION_ERROR_NEW_ACCEPTED_STATUS' => AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS,
'VALIDATION_ERROR_ACK_REJECTED_STATUS' => AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_REJECTED_STATUS,
'VALIDATION_ERROR_ACK_ACCEPTED_STATUS' => AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_ACCEPTED_STATUS,
'isSanitizationAutoAccepted' => self::is_sanitization_auto_accepted(),
'blockSources' => $block_sources ? $block_sources->get_block_sources() : null,
'pluginNames' => $plugin_names,
'themeName' => wp_get_theme()->get( 'Name' ),
'themeSlug' => wp_get_theme()->get_stylesheet(),
];
wp_add_inline_script(
$slug,
sprintf(
'var ampBlockValidation = %s;',
wp_json_encode( $data )
),
'before'
);
if ( function_exists( 'wp_set_script_translations' ) ) {
wp_set_script_translations( $slug, 'amp' );
} elseif ( function_exists( 'wp_get_jed_locale_data' ) || function_exists( 'gutenberg_get_jed_locale_data' ) ) {
$locale_data = function_exists( 'wp_get_jed_locale_data' ) ? wp_get_jed_locale_data( 'amp' ) : gutenberg_get_jed_locale_data( 'amp' );
$translations = wp_json_encode( $locale_data );
wp_add_inline_script(
$slug,
'wp.i18n.setLocaleData( ' . $translations . ', "amp" );',
'after'
);
}
}
}